02 讲

问题:文中的例子里说,窗口大小发生变化,组件就会更新。对于这一点我不太理解,Class 封装的还可以理解为,state 发生改变了,导致重新 render 了,但 Hooks 感觉这么理解并不通顺,Hook 哪里写得就类似一个纯函数调用,是怎么驱动组件重新更新的呢?

讲解:一个很好的问题,这是很多初学 Hooks 的同学都会有的困惑:一个普通函数怎么就让组件刷新了呢?其实答案也特别简单,那就是自定义 Hooks 中也是通过 useState 这样的内置 Hook 来完成组件的更新的

可能你会觉得,自定义 Hooks 中一定会用到 state 吗?如果你写多了就会发现,自定义 Hooks 要实现的逻辑,要么用到 state,要么用到副作用,是一定会用到内置 Hooks 或者其它自定义 Hooks 的。

如果对比 Class 组件,state 发生变化,导致重新 render。这个在 Hooks 中是完全一样的,也是 state 发生变化,导致重新 render,只是我们可以在 Hooks 这样额外的函数里去 set state,而 Class 组件只能在当前 Class 中 set state。

03 讲

问题 1:函数体也是每次 render 都会执行,那么,需要每次都会 render 执行的语句是放在 无依赖的 useEffect 中呢,还是直接放在函数体中比较好呢?

讲解:这两种情况的语义是不一样的。useEffect 代表副作用,是在函数 render 完后执行。而函数体中的代码,是直接影响当次 render 的结果。

所以在写代码的时候,我们一定要理解每个 API 的语义,副作用一定是和当前 render 的结果没关系的,而只是 render 完之后做的一些额外的事情。

问题 2:老师你好,我看到 redux 官网实现 useActions 函数,让我很困惑:

地址:https://react-redux.js.org/api/hooks#recipe-useactions

摘录源码,它的依赖数组是动态的,这肯定是不对的,但是如何在 eslint-plugin-react-hooks 规则下写这个函数的呢?:

import { bindActionCreators } from 'redux'  
import { useDispatch } from 'react-redux'  
import { useMemo } from 'react'  
   
export function useActions(actions, deps) {  
const dispatch = useDispatch()  
return useMemo(  
() => {  
if (Array.isArray(actions)) {  
return actions.map(a => bindActionCreators(a, dispatch))  
}  
return bindActionCreators(actions, dispatch)  
},  
// 这个依赖数组不是常量的  
deps ? [dispatch, ...deps] : [dispatch]  
)  
}

讲解:需要注意的是,ESLint 的作用是帮助你发现可能存在的错误,而 Hooks 本身并不需要依赖数组是常量,只要你确定写法没有问题,那么这种可以忽略 eslint 的配置,比如加上 //eslint-disable-line 这样的注释。类似的,如果要用 Hooks 实现一个 componentDidMount 这样的功能,我们看到是需要传递一个空的数组作为 useEffect 的依赖项,那么这时候即使副作用内部使用了某些变量,那么只要你确定它只有第一次需要用到,后面无需再关心,那么也可以在这一行禁用 ESLint 的检查。所以 ESLint 主要是一个辅助的作用,代码的正确性是可以完全由自己来判断的。

04 讲

问题 1:老师,有两种写法,请问在性能方面是否后者优于前者?写法如下:

const handleIncrement = useCallback(() => setCount(count + 1), [count]);

const handleIncrement = useCallback(() => setCount(q => q + 1), []);

我的理解是这样的:后者只创建了一次函数,但是又调用了多次在 setCount 的回调函数。前者只会在 count 变化的时候创建新的回调函数。这样分析下来我又觉得两者没什么差异。我不是太清楚这两者的优缺点,希望得到老师的解答。

讲解:确实后者是更好的写法,因为 handleIncrement 不会每次在 count 变化时都使用新的。从而接收这个函数的组件 props 就认为没有变化,避免可能的性能问题。

但是有时候如果 DOM 结构很简单,其实怎么写都没什么影响。但两种代码实际上都是每次创建函数的,只是第二种写法后面创建的函数是被 useCallback 忽略的。

所以这里也看到了 setState 这个 API 的另外一种用法,就是可以接收一个函数作为参数:setSomeState(previousState => {})。这样在这个函数中通过参数就可以直接获取上一次的 state 的值了,而无需将其作为一个依赖项。这样做可以减少一些不必要的回调函数的创建。

问题 2:是任何场景,函数都用 useCallback 包裹吗?那种轻量的函数是不是不需要?

讲解:对于简单的 DOM 结构,或者函数不被作为属性传递到依赖或者组件的属性,那么用不用 useCallback 都可以。和函数是否轻量无关,主要和组件的复杂度有关。但是始终使用 useCallback 是个比较好的习惯。

课后思考题

接下来主要是针对第 6、7 讲的思考题进行回答。前几节课的思考题,我看到评论区已经有非常优秀的回答,而且有的同学也贴出了自己的代码示例,这都是非常不错的学习方法。而且我也把这些优秀的回答进行了置顶,方便大家交流讨论。

06 讲

题目:在 useCounter 这个例子中,我们是固定让数字每次加一。假如要做一个改进,允许灵活配置点击加号时应该加几,比如说每次加 10,那么应该如何实现?

讲解:这里要考察的是你有没有意识到 Hooks 就是普通函数,是可以给它传递任意参数的。所以我们只要由调用这决定加几就可以了:

function useCounter(n) {  
  // 定义 count 这个 state 用于保存当前数值  
  const [count, setCount] = useState(0);  
  // 实现加 n 的操作  
  const increment = useCallback(() => setCount(count + n), [count]);  
  // 实现减 n 的操作  
  const decrement = useCallback(() => setCount(count - n), [count]);  
  // 重置计数器  
  const reset = useCallback(() => setCount(0), []);  
    
  // 将业务逻辑的操作 export 出去供调用者使用  
  return { count, increment, decrement, reset };  
}

07 讲

题目:只考虑 Redux 部分,对于计数器应用,目前每次是固定加减 1,如果要能够在每次调用时增加或减少指定的变量值,应该如何实现?

讲解:这和上一讲的思考题几乎一样,只是这里考察的是,有没有注意到 Redux 的 action 就是一个普通的 object,我们可以在其中加入任何需要的参数,只要 reducer 能处理就可以了。

代码如下:

const incrementAction = {  
  type: 'counter/incremented',   
  n: 5, // 实现每次加5   
};  
  
function counterReducer(state = initialState, action) {  
  switch (action.type) {  
    case 'counter/incremented':  
      // 从 action 中去拿每次加几  
      return { value: state.value + action.n }  
    default:  
      return state  
  }

第 9 讲

题目 1:article?.userIdarticle&&article.userId 的作用是一样的吗?第一次见这种写法,感觉好简洁。

回答:虽然这是一个 JS 的语法问题,但是因为是一个新语法,所以既然有同学问了,就拿出来讲一下。简单来说,“?." 是一个名为 optional chaining 的新语法,是刚刚进入 ECMAScript 的标准。借助于 Babel 我们现在可以放心使用。

article?.userId 和 article && article.userIde 这两种写法功能是基本等价的,就是判断 article 是否存在,如果存在则获取 userId 属性,否则就是 undefined。这样的话可以避免 JS 运行时的报错。唯一的一点区别在于,后者 && 的写法其实如果 article 为 null 或者 undefined 或者 0 等 falsy 的值时,会返回这个 falsy 的值本身,比如 null,undefined 或者 0。虽然这在大多数情况下是不用考虑这种差别的。

很显然,optional chaining 这种语法更简洁语义也更明确。下面的代码展示了它的用法:

// 静态属性  
const c = foo?.bar?.c;  
// 动态属性,数组属性  
const a = obj?.[key].a;  
// 方法调用  
obj.getSomething?.();

题目 2:对于 loading 和错误处理,我们项目是在做全局处理的,且 loading 是通过 redux 管理的。那么这种做法,跟这节课所讲的方法相比,究竟应该使用哪种方法呢?

回答:两者不冲突,在 Hooks 中课程里是用的 useState,但是如果 loading 和 error 是在 Redux 中,那么可以用 useSelector 去获取状态。全局处理的好处是方便多组件去重用状态,而且可以避免重复的请求。

比如两个组件都需要去使用 Article 数据,如果用 useState 保存数据,那么两个组件就都有各自的数据。如果放到 redux 中,那么逻辑就可以根据是否已经在 loading 来决定是不是要发起请求,还是等待已有的请求结束。

第 10 讲

题目:HOC 存在的意义在哪里呢?感觉能够用 HOC 的场景,其实都可以用 render props 来替代。而且从逻辑角度讲,render props 的逻辑更清晰,HOC 的使用逻辑则更加冗余。所以实在想不出来有什么场景是一定需要使用 HOC 的。

回答:HOC 如果作为 Controller ,而不是提供额外属性时,其语义是更加清晰的。比如说在第 14 课提到的浮动层,我们希望对话框在不可见时就不执行任何逻辑,那么可以用 HOC 来实现一个这样的 Controller,比如下面的代码:

const wrapModal = Modal  
  => ({visible, ...props})  
  => visible ? <Modal visible {...props} /> : null;

对于一个 Modal 组件,当 visible 为 false 时,就直接返回 null 而不是去 render Modal 组件。这里就用 HOC 的模式新建了一个 Controller 性质的组件,来重用这种逻辑。

第 12 讲

题目 3:js-plugin 的用处感觉比较大。但是在 react 组件中, 这样的写法,我确实不太能理解。就像是一个容器组件,通过传入不同的参数,返回不同的组件。因为我使用 ts,感觉这样又少了类型检查。

回答:是的,很类似一个容器组件。但是这个容器组件的内容来自于松耦合的不同部分的内容。不是从容器组件内部依赖其它实现,而是反方向,外部的功能自己提供内容到这个容器组件。对于 TypeScript,确实是不够友好的,因为太动态了。但是其实没有类型检查不会是太大问题,因为内容提供者和消费者两者唯一的连接点是 article.footer 这个扩展点以及其函数签名。当然,如果一定要支持 TypeScript,也是可行的,只是比较繁琐。那就是为每个扩展点提供一个类型定义文件,然后在创建 plugin 时,plugin 对象需要满足所有支持的扩展点的类型定义。

第 13 讲

题目 1:虽然这个 API 只支持通过函数执行进行验证,但是我们很容易进行扩展,以支持更多的类型,比如正则匹配、值范围等等。这个能演示一下吗?

回答:要支持更多类型的验证,就是判断当前提供的 validator 的类型,比如如果发现提供的是正则,就用正则匹配,如果是一个包含了 min,max 属性的对象就进行自动的范围判断,如果是函数,那么就调用函数,比如下面的代码在原有的基础上提供了这几种验证机制的支持:

if (validators[name]) {  
  const validator = validators[name];  
  let errMsg;  
  if (typeof validator === 'function') {  
    // 如果是函数,就调用函数验证  
    errMsg = validator(value);  
  } else if (validator.min) {  
    // 如果指定了 min,就判断是否小于 min  
    if (value < validator.min) errMsg = `数值需要小于 ${value}`;  
  } else if (validator.max) {  
    // 如果提供了 max,就判断是否大于 max  
    if (value > validator.max) errMsg = `数值需要大于 ${value}`;  
  } else if (validator.test) {  
    // 简单的通过是否有 test 方法来确定是不是正则表达式  
    if (!validator.test(value)) errMsg = `${name} 需要匹配正则。`  
  }  
   
  setErrors((errors) => ({  
    ...errors,  
    [name]: errMsg || null,  
  }));  
}

这样我们就支持了多种形式的验证,但其实只要提供了函数类型,就能够支持任意形式的同步验证了。加入更多类型只是为了方便这个 Form 机制的使用。

第 14 讲

题目 1:NiceModal 里面有 const modal = useNiceModal(id); 在 MyModalExample 也用了 const modal = useNiceModal("my-modal"); 用了两次 useNiceModal,返回值的两个 modal 应该是不同的对象吧? modal 里面的 hide、show 函数应该也是不一样的,对吗?不过数据都存在 const store = createStore(modalReducer) 的 store 里了,是全局的。

回答:是的,两个 modal 是不同的对象,show,hide 函数也不一样。useModal 返回的只是用于控制某个对话框的一个 handler。这里的关键在于要理解自定义 Hook 就相当于一个自定义组件,内部使用的任何其它 Hooks,比如 useState,useEffect 都是和外层组件毫无关系的。他们都是存在于自己的作用域里,通过参数和返回值和调用者交互。所以,要实现 Hooks 之间的状态共享,仍然需要 Context,Redux 等全局的状态管理机制。

题目 2:在课程中,我们使用的是 Redux 来管理所有对话框的所有状态。但有时候你的项目并不一定使用了 Redux,那么我们其实也可以使用 Context 来管理对话框的全局状态。那么请你思考一下,如果基于 Context ,应该如何实现 NiceModal 呢?

回答:在基础篇中你曾看到过 useContext 的用法,它也是实现全局状态管理的一个机制。但是,当时并没有提到 useReducer 这个从 Redux 借鉴过来的 API。没有介绍的原因其实在于我个人觉得 useReducer 其实并不常用,因为通常来说我们使用 Redux 等独立的框架会更加方便,因为 API 更加丰富,以及生态更完善,比如 Redux 的一些中间件等等。

但是,在这道题目中,虽然可以不用 useReducer,但是如果使用的话,是可以几乎完全重用课程中例子的代码的。因为 Redux 和 useReducer 的 reducer 和 action 的实现是完全兼容的。这也是为什么很多人说,Context + useReducer 是几乎可以替代 Redux 的。下面的代码展示了使用 Context 的实现:

import React, { useContext, useReducer } from 'react';  
  
// 使用一个 Context 来存放 Modal 的状态  
const NiceModalContext = React.createContext({});  
// modalReducer 和 Redux 的场景完全一样  
const modalReducer = ...  
function NiceModalProvider({ children }) {  
  const [modals, dispatch] = useReducer(modalReducer,{});  
  return (  
    <NiceModalContext.Provider value={{ modals, dispatch }}>  
      {children}  
    </NiceModalContext.Provider>  
  );  
};

因为使用了 Context,所以需要在整个应用的根节点创建 Context 并提供所有对话框的状态管理机制。这里也看到了一个非常关键的组合,就是使用了 useReducer 来管理一个复杂的状态,然后再将这个状态作为 Context 的 value。需要注意的是,useReducer 管理的仍然是一个组件的本地状态,和 useState 类似,但其实单个组件很少有状态需要 useReducer 这样的高级功能来管理,所以一般其实 useReducer 都是和 Context 一起使用的,去管理一个更大范围的数据状态。

第 15 讲

题目 1:原文写:“如果你的事件处理函数是传递给原生节点的,那么不写 callback,也几乎不会有任何性能的影响。”为什么这么说呢?是不是因为原生节点,本来就要不断渲染呢?

回答:要回答这个问题,关键还在于理解在 useCallback 究竟是为了解决什么问题。问题的本质在于,React 组件是通过引用比较来判断某个值是否发生了变化,如果变化了,那么组件就需要重新渲染,以及虚拟 DOM 的 diff 比较。那么,如果每次传过去不同的函数,即使这些函数的功能完全一样,那也会导致组件被刷新。所以,useCallback, useMemo 就是通过缓存上一次的的结果来确保如果功能没变,那么就使用同样的函数,来避免重新渲染。

所以,useCallback,useMemo 只是为了避免 React 组件的重复渲染而导致的性能损失。而对于原生的节点,比如 div, input 这些,它们已经是原子节点了,不再有子节点,所以不存在重复刷新带来的性能损失。

第 16 讲

题目 1:对于 react-loadable 和 Service Worker 有两点比较疑惑的地方。

(1)react-loadable 和 React.lazy() 的使用场景有什么不一样吗?还是说,使用 react-loadable 的地方都可以使用 React.lazy() 代替?

回答:确实,两者确实可以解决一样的问题,毕竟 import() 这个语句是由 Webpack 来处理,最终实现分包的。所以结合 React.lazy 和 Suspense 可以实现 react-loadable 的功能。但是呢,react-loadabale 提供了更丰富的 API,比如可以设置如果加载事件小于 300 毫秒,就不显示 loading 状态。这在使用 service worker 时可以让用户感知不到按需加载的存在,体验更好。因为有研究表明,如果加载时间本来很短,你却一闪而过一个 loading 状态,会让用户觉得时间很长。另外就是,react-loadable 提供了服务器端渲染的支持,而 React.lazy 是不行的。

(2)在 Service Worker 中使用 Cache Storage 来缓存静态资源,是否有容量大小的限制呢?如果是在缓存 svg、png 等其他格式的静态资源的时候,是否有什么限制呢?

回答:Cache 的大小并没有统一的规定,各个浏览器会提供不同的 size,但一个共识是根据当前磁盘剩余大小去调整,从而决定是否能继续存储数据。但是,无论 Size 是多少,我们都需要及时清除不需要的数据,以及一定要处理存储失败的场景,转而使用服务器端的数据。从而保证即使 Cache 满了,也不影响功能的使用。